MB-72489: top-N collector optimizations - #2385
Conversation
… depth Before: container/heap (binary, log₂N comparisons per siftDown) After: inline ternary heap (3 children/node, log₃N comparisons, removed dependency) The top-N collector maintains a min-heap (worst score at root) over the K best results seen so far. Every new candidate triggers a siftDown traversal from the root. A binary heap takes log₂(N) levels per traversal; a ternary heap takes log₃(N): k=10: binary ~3.3 levels → ternary ~2.1 levels (−36%) k=100: binary ~6.6 levels → ternary ~4.2 levels (−37%) k=1000: binary ~10 levels → ternary ~6.3 levels (−37%) Comparing 3 children per step rather than 1 means all three fit in 1-2 cache lines instead of 1 — fewer cache-line fetches per traversal at the cost of one extra comparison per step. Net win: shallower tree beats extra comparison. Implementation: siftUp/siftDown inline methods on collectStoreHeap using child formula 3i+1, 3i+2, 3i+3. Removes the container/heap import entirely. ~67 lines changed; zero interface changes. Measured improvement on M2 Pro (3-term BM25, MAXSCORE path): k=10: 905µs → 892µs (~1.5%) k=100: 922µs → 911µs (~1.2%) k=1000: 1395µs → 1354µs (~3%) Benefit scales with k — meaningful for top-1000, small for top-10. Composes cleanly with §1 WAND: fewer candidates reach the heap, so fewer siftDowns overall; a faster heap multiplies whatever fraction remains. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> (cherry picked from commit 2ed83c8)
…ion) collectStoreHeap.Final was 11.6% of total CPU when profiling k=1000 BM25 queries. Extracting k docs via repeated removeLast (heapsort) requires O(k log3 k) comparisons with scattered pointer dereferences at each heap level - poor cache behavior. Fix: sort.Slice in-place (pdqsort) then copy heap[skip..skip+size-1] sequentially. Final cum cost: 1.29s -> 0.52s (-60%). Note: the original perf-gar commit (89fa0a1 on perf-gar-v17-only) also specialized the score-descending comparator; that half is already on master via MB-72489 (#2381) as getOptimalCollectorCompare, so this cherry-pick carries only the Final() change. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> (cherry picked from commit 89fa0a1)
Adds 9 unit tests for the collectStoreList type in search/collector that had 0% coverage before this commit. Tests cover round-trip insertion order, size-capped eviction (AddNotExceedingSize), skip-based pagination (Final), Internal() ascending traversal, removeLast worst-doc eviction, single-element and equal-score edge cases, and fixup error propagation — all using existing scoreDesc / makeScoreDoc helpers from heap_test.go. These tests have no dependency on any perf-gar change and can be rebased or cherry-picked independently. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> (cherry picked from commit 7de1081)
Reset saves five backing arrays and a map, wipes the struct with
*dm = DocumentMatch{}, then restores them. That wipe is a ~240-byte duffzero, and
most of what it clears is either about to be overwritten or already zero.
Zero the nine fields that are NOT restored below instead. Same postconditions,
no duffzero.
Reset runs once per collected document, so this is on the hottest per-document
path there is — it shows up on single-term queries, not just disjunctions.
Split out of perf-gar-mod-fix-2's 5d9689f, which bundled this with a §15
minSegCeiling guard. The two are unrelated: the guard needs the per-segment score
ceilings and therefore the v18 MaxTFNorm sidecar, while this is pure in-memory work
that applies to any segment format. Only this half belongs on a v17-only branch.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Guard pointer/string/map field zeroing in Reset with nil/empty checks so the common case (no explain, no fragments, no field highlights) skips nil-to-nil stores and their GC write barriers (~5 cycles per field, across the millions of Reset calls in a large result collection). Note: the original perf-gar commit (1b30de4 on perf-gar-v17-only) also added the prepareDocumentMatch fast path and the non-KNN adjustDocumentMatch guard; both are already on master via MB-72489 (#2381) as canFastPrepare() and adjustKNNDocumentMatch, so this cherry-pick carries only the Reset() change. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> (cherry picked from commit 1b30de4)
With Score="none" and a bounded Size, the request means "return any Size+From
matching documents" — nothing about the result depends on the matches beyond that.
So the collector can stop pulling from the searcher once it has them, instead of
draining the entire match set to count it.
On a 500k-doc corpus this is the difference between scanning every match and
scanning ten: BenchmarkEarlyStopTermTier1ScoreNone goes 18.15ms -> 8.4us and
EarlyStopDisjMin2ScoreNone 41.79ms -> 22.6us on the full branch.
Because the scan really does stop counting, Total becomes a lower bound, and saying
otherwise would silently mislead callers. This adds SearchResult.TotalRelation
("eq" | "gte") to report that, plus the merge rule that any constituent reporting
"gte" makes a merged Total "gte" too.
Gated on the result being independent of unseen documents — no facets (every match
must be counted into the buckets), no KNN (separate hit set), no SearchAfter (the
cursor depends on the full ordering), no nested rollup, no reverse execution, and
sort-by-score only (which degrades to arrival order under score="none"; a field
sort could have its top-k anywhere in the match set).
Two deviations from perf-gar-mod-fix-2's version of this work, both because that
branch's WAND machinery is not present here:
- TotalRelation is introduced here rather than in 0f56552, which bundled the
public API with the WANDPruned collector plumbing and the disjunction-searcher
changes that set it. On this branch the bounded scan is the only producer of a
lower-bound Total, so the API arrives with its first user and nothing about it
is dead.
- index_impl derives TotalRelation from coll.EarlyStopped() alone, not
"coll.WANDPruned() || coll.EarlyStopped()".
The upstream commit shipped no tests. Added earlystop_test.go, because the
preconditions above are the whole correctness argument and getting one wrong is a
silent wrong-answer bug rather than a slowdown: it checks that a bounded scan still
returns Size hits and reports "gte", that every returned hit is a real match, and
that the scan does NOT engage for facets, field sort, SearchAfter or scoring —
each of which must still report an exact Total.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR introduces a set of performance-focused changes around the top-N collector and DocumentMatch reuse, plus an early-stop optimization for score="none" queries that can safely return any Size+From matches. To keep the observable behavior explicit, it also adds SearchResult.TotalRelation ("eq"/"gte") to indicate when Total is an exact count vs a lower bound due to bounded scanning.
Changes:
- Replace
container/heapwith an inline ternary heap implementation and optimize final result extraction via a single in-placesort.Slice. - Optimize
DocumentMatch.Reset()to avoid full-struct zeroing and skip nil→nil stores to reduce GC barrier overhead. - Add bounded-scan early-stop for safe
score="none"+Size>0cases, and expose the newTotalRelationfield to report lower-bound totals.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| search/search.go | Optimizes DocumentMatch.Reset() by targeted zeroing and guarded nil stores while preserving reusable buffers. |
| search/collector/topn.go | Adds early-stop support to TopNCollector and exposes SetEarlyStop/EarlyStopped. |
| search/collector/list_test.go | Adds coverage for the linked-list collector store behavior. |
| search/collector/heap.go | Replaces stdlib binary heap with a ternary heap and optimizes Final() via in-place sorting. |
| search/collector/heap_test.go | Adds tests for ternary heap invariants and Final() behavior. |
| search.go | Adds TotalRelationEq/Gte constants and SearchResult.TotalRelation, and propagates the relation in Merge. |
| index_impl.go | Enables early-stop under strict preconditions and sets TotalRelation based on whether collection bounded early. |
| earlystop_test.go | Adds integration tests validating bounded scan behavior and the TotalRelation contract. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| func (hc *TopNCollector) SetEarlyStop(n int) { | ||
| hc.earlyStopN = n | ||
| } |
| // Merge will merge together multiple SearchResults during a MultiSearch | ||
| func (sr *SearchResult) Merge(other *SearchResult) { | ||
| sr.Status.Merge(other.Status) | ||
| sr.Hits = append(sr.Hits, other.Hits...) | ||
| sr.Total += other.Total | ||
| if other.TotalRelation == TotalRelationGte { | ||
| // Any constituent whose Total is a lower bound makes the merged Total one. | ||
| sr.TotalRelation = TotalRelationGte | ||
| } |
| // TestTernaryHeapFinalWithSkip verifies that Final(skip, ...) skips the skip | ||
| // worst results and returns the rest best-first. | ||
| func TestTernaryHeapFinalWithSkip(t *testing.T) { |
Thejas-bhat
left a comment
There was a problem hiding this comment.
please tag the associated MB-72489
… trim comments - remove collectStoreList + its test (type is unused in the collector package) - Reset(): zero fields directly without nil guards - replace verbose early-stop comments with concise notes at the call site
|
also, i feel like the PR description is way more verbose than necessary. can you please remove the redundant information that's there, and also update it with the latest patch in mind (removing the collectorList impl for eg) |
bleve PR: blevesearch/bleve#2385 Change-Id: I5e213e49308a39f676d123b99f47336ba2412389 Reviewed-on: https://review.couchbase.org/c/cbft/+/249641 Well-Formed: Build Bot <build@couchbase.com> Reviewed-by: <thejas.orkombu@couchbase.com> Tested-by: Rohit Parashar <rohitp.kumar@couchbase.com>
Top-N collector optimizations:
Changes:
container/heapwith an inline ternary heap in the top-N collector — shallower siftDown, no interface dispatch. Collector benchmarks: ~14% faster (a hand-rolled binary heap measures ~10%, benchstat, n=10).collectStoreHeap.Final(): extract results via one in-placesort.Slice+ sequential copy instead of repeated heap pops.DocumentMatch.Reset(): zero only the fields not restored afterwards, instead of wiping the whole struct.score=none+Size: stop scanning onceSize+Fromhits are collected, when nothing depends on unseen matches (no facets, KNN,SearchAfter, nested, or field sort). AddsSearchResult.TotalRelation("eq"/"gte") sinceTotalbecomes a lower bound for such requests.collectStoreListtype.